今天做一個可直接上課/活動用的抽籤/點名器。設計目標很單純:
最小可用(MVP):匯入名單 → 抽一個 → 不重複 → 看得見剩餘與已抽中
整個工具只用 Python 標準庫(Tkinter、csv、random、pathlib),無需安裝任何套件。
為什麼這樣設計?
功能清單(MVP)
檔案格式說明
TXT
王小明
李小華
陳大雄
CSV(取第一欄,不當作表頭)
姓名,系級
王小明,資工三
李小華,資工四
程式碼(存成 raffle_gui_basic.py)
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import csv, random
from pathlib import Path
class RaffleBasic:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("抽籤/點名器 - 基礎版")
# 狀態
self.pool = [] # 尚未抽到的名單
self.winners = [] # 已抽中的名單(依抽出順序)
self.rng = random.SystemRandom()
self._build_ui()
self._refresh_counts()
def _build_ui(self):
main = ttk.Frame(self.root, padding=16); main.grid(sticky="nsew")
self.root.rowconfigure(0, weight=1); self.root.columnconfigure(0, weight=1)
main.columnconfigure(0, weight=1); main.columnconfigure(1, weight=1)
main.rowconfigure(2, weight=1)
# 控制列
top = ttk.Frame(main); top.grid(row=0, column=0, columnspan=2, sticky="we", pady=(0,8))
ttk.Button(top, text="匯入名單", command=self.import_list).grid(row=0, column=0, padx=4)
ttk.Button(top, text="重置", command=self.reset_all).grid(row=0, column=1, padx=4)
# 顯示當前抽中
display = ttk.Frame(main); display.grid(row=1, column=0, columnspan=2, sticky="we", pady=8)
display.columnconfigure(0, weight=1)
ttk.Label(display, text="抽中:", font=("Segoe UI", 11)).grid(row=0, column=0, sticky="w")
self.var_current = tk.StringVar(value="—")
ttk.Label(display, textvariable=self.var_current, font=("Segoe UI", 20)).grid(row=1, column=0, sticky="w")
# 左:候選
left = ttk.Frame(main); left.grid(row=2, column=0, sticky="nsew", padx=(0,8))
left.columnconfigure(0, weight=1); left.rowconfigure(1, weight=1)
ttk.Label(left, text="候選名單").grid(row=0, column=0, sticky="w")
self.list_pool = tk.Listbox(left, height=12)
self.list_pool.grid(row=1, column=0, sticky="nsew")
# 右:已抽中
right = ttk.Frame(main); right.grid(row=2, column=1, sticky="nsew")
right.columnconfigure(0, weight=1); right.rowconfigure(1, weight=1)
ttk.Label(right, text="中獎名單(最新在最上方)").grid(row=0, column=0, sticky="w")
self.list_win = tk.Listbox(right, height=12)
self.list_win.grid(row=1, column=0, sticky="nsew")
# 底部:新增+抽取+統計
bottom_left = ttk.Frame(main); bottom_left.grid(row=3, column=0, sticky="we", pady=(8,0))
ttk.Label(bottom_left, text="新增姓名").grid(row=0, column=0)
self.entry_new = ttk.Entry(bottom_left, width=18); self.entry_new.grid(row=0, column=1, padx=4)
ttk.Button(bottom_left, text="加入", command=self.add_name).grid(row=0, column=2, padx=4)
bottom_right = ttk.Frame(main); bottom_right.grid(row=3, column=1, sticky="we", pady=(8,0))
ttk.Button(bottom_right, text="抽一個 (Space/Enter)", command=self.draw_one).grid(row=0, column=0, padx=4)
self.lbl_counts = ttk.Label(bottom_right, text="剩餘:0|已抽:0")
self.lbl_counts.grid(row=0, column=1, sticky="e")
# 快捷鍵
self.root.bind("<space>", lambda e: self.draw_one())
self.root.bind("<Return>", lambda e: self.draw_one())
# ---- 功能 ----
def _refresh_counts(self):
self.lbl_counts.config(text=f"剩餘:{len(self.pool)}|已抽:{len(self.winners)}")
def _reload_pool(self):
self.list_pool.delete(0, tk.END)
for n in self.pool:
self.list_pool.insert(tk.END, n)
def import_list(self):
path = filedialog.askopenfilename(
title="匯入名單(TXT 或 CSV)",
filetypes=[("文字檔或 CSV", "*.txt *.csv"), ("所有檔案", "*.*")]
)
if not path: return
p = Path(path)
names = []
try:
if p.suffix.lower() == ".csv":
with p.open("r", encoding="utf-8-sig", newline="") as f:
for row in csv.reader(f):
if row and row[0].strip():
names.append(row[0].strip())
else:
with p.open("r", encoding="utf-8") as f:
for line in f:
name = line.strip()
if name:
names.append(name)
except Exception as e:
messagebox.showerror("匯入失敗", str(e)); return
# 去重(不重複加入既有 pool / winners)
existing = set(self.pool) | set(self.winners)
added = [n for n in names if n not in existing]
if not added:
messagebox.showinfo("提示", "沒有可新增的項目(可能全都重複)"); return
self.pool.extend(added)
self._reload_pool(); self._refresh_counts()
messagebox.showinfo("完成", f"已匯入 {len(added)} 筆(略過重複)")
def add_name(self):
name = self.entry_new.get().strip()
if not name: return
if name in self.pool or name in self.winners:
messagebox.showwarning("重複", f"「{name}」已存在"); return
self.pool.append(name)
self.entry_new.delete(0, tk.END)
self._reload_pool(); self._refresh_counts()
def reset_all(self):
if messagebox.askyesno("重置", "清空中獎名單並回復所有候選?"):
self.pool += self.winners
self.winners.clear()
self.var_current.set("—")
self._reload_pool()
self.list_win.delete(0, tk.END)
self._refresh_counts()
def draw_one(self):
if not self.pool:
messagebox.showinfo("提示", "候選名單已抽完,請重置或新增成員"); return
idx = self.rng.randrange(len(self.pool))
name = self.pool.pop(idx)
self.winners.append(name)
self.var_current.set(name)
self._reload_pool()
self.list_win.insert(0, name)
self._refresh_counts()
self.root.bell()
def main():
root = tk.Tk()
app = RaffleBasic(root)
root.mainloop()
if __name__ == "__main__":
main()
使用方式
python raffle_gui_basic.py
操作流程
點「匯入名單」選 TXT/CSV 檔(或在左下角用「新增姓名」補人)。
直接按「抽一個」或鍵盤 Space/Enter。
「中獎名單」會把最新的放最上方,方便投影查看。
抽完會提示「候選名單已抽完」,可按「重置」把人放回去再抽。
實作:

常見問題(FAQ)
1.匯入 CSV 亂碼?
用 UTF-8 或 UTF-8 with BOM(utf-8-sig)匯出最安全。
2.名單重複?
會自動略過已存在於候選或已中獎清單中的名字。
3.抽到空白/雜訊?
TXT 建議每行只放姓名;CSV 以第一欄為姓名,其餘欄位會被忽略。
4.抽到一半想加人?
直接在「新增姓名」輸入框加入;不影響已抽中的結果。
5.怎麼保證公平?
使用 random.SystemRandom() 取自系統熵源,適合抽籤/點名用途。
Day 27 預告
復原上一抽(撤回剛抽出的名字)
抽五個(批次)
雙擊候選清單刪除
匯出中獎 CSV(含順序)